Skip to content

Conversation

@bvaughn
Copy link
Contributor

@bvaughn bvaughn commented May 8, 2018

React Native currently uses the context API to warn about invalid nesting of Text and View components. Some of this is currently necessary because of conditional logic like this. Other parts could be moved here though (like View warning if it's within a Text ancestor) in order to remove the necessity of using an extra Context.Consumer component. Planned changes to React Native may also allow for moving other Context.Consumer components from the JS side, which would make this warning approach more attractive.

cc @yungsters

parentHostContext: HostContext,
type: string,
): HostContext {
return {type};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't need this in PROD, let's avoid the extra allocation? Like ReactDOM does.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for the root one.

parentHostContext: HostContext,
type: string,
): HostContext {
return {type};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is providing a new context object for every node in the tree. We should only do this in DEV and even in DEV we should only do this when we are switching between useful context differences (e.g. from non-text to text). In DOM we change the context only when we switch between HTML and SVG, not every node along the way.

Copy link
Contributor Author

@bvaughn bvaughn May 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, hadn't yet pushed the DEV only check, but it's there now.

Interesting suggestion with the second bit. I'll add that.

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All changes now needs to be replicated in the Fabric renderer too.

@pull-bot
Copy link

pull-bot commented May 8, 2018

Details of bundled changes.

Comparing: fc3777b...2ca4555

react-native-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
ReactNativeRenderer-dev.js +0.2% +0.2% 470 KB 470.75 KB 100.5 KB 100.72 KB RN_FB_DEV
ReactNativeRenderer-prod.js 🔺+0.3% 🔺+0.5% 225.13 KB 225.73 KB 37.67 KB 37.87 KB RN_FB_PROD
ReactNativeRenderer-dev.js +0.2% +0.2% 469.73 KB 470.48 KB 100.44 KB 100.66 KB RN_OSS_DEV
ReactNativeRenderer-prod.js 🔺+0.3% 🔺+0.6% 219.24 KB 219.84 KB 36.6 KB 36.81 KB RN_OSS_PROD
ReactFabric-dev.js +0.2% +0.2% 452.39 KB 453.14 KB 96.08 KB 96.29 KB RN_FB_DEV
ReactFabric-prod.js 🔺+0.3% 🔺+0.6% 204.88 KB 205.5 KB 34 KB 34.19 KB RN_FB_PROD
ReactFabric-dev.js +0.2% +0.2% 452.42 KB 453.17 KB 96.09 KB 96.31 KB RN_OSS_DEV
ReactFabric-prod.js 🔺+0.3% 🔺+0.6% 204.92 KB 205.54 KB 34.02 KB 34.21 KB RN_OSS_PROD

Generated by 🚫 dangerJS

@bvaughn
Copy link
Contributor Author

bvaughn commented May 8, 2018

All changes now needs to be replicated in the Fabric renderer too.

Ah, right. Okay~ added to fabric too.

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the flaw here is that currently it's not just a DEV only thing. Its native protocol is contextual. So even if you add this host context, you can't remove the context from the wrapper.

https://github.com/facebook/react-native/blob/master/Libraries/Text/Text.js#L218-L222

It renders different components depending on where you are. @shergin is fixing this on iOS Fabric to not be needed but currently it's needed on the Android side.

I think the solution to that is to fix the native side so that the difference between RCTVirtualText and RCTText isn't necessary to deal with in JS. Maybe. Although it might be nice to be able to optimize from JS.

if (__DEV__) {
const oldIsInAParentText = parentHostContext.isInAParentText;
const newIsInAParentText =
type === 'RCTText' || type === 'RCTVirtualText';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new parent can never be a RCTVirtualText. Because of this:

https://github.com/facebook/react-native/blob/master/Libraries/Text/Text.js#L218-L222

Which highlights that this is in fact not just a DEV only contextual thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new parent can never be a RCTVirtualText.

I'm not sure I understand what you're saying here.

getChildHostContext() can get called with type === "RCTVirtualText" in cases like:

<Text>
  <Text>this is inside of a virtual text</Text>
</Text>

Which highlights that this is in fact not just a DEV only contextual thing.

I don't understand. The "DEV only" bit here is the warning, not the runtime behavior.

@bvaughn
Copy link
Contributor Author

bvaughn commented May 8, 2018

I think the flaw here is that currently it's not just a DEV only thing. Its native protocol is contextual. So even if you add this host context, you can't remove the context from the wrapper.
I think the solution to that is to fix the native side so that the difference between RCTVirtualText and RCTText isn't necessary to deal with in JS...

Agreed, but I think the DEV warning about View not being allowed inside of a text, or string/text not being allowed outside of a Text is still valid. No? The Android-specific conditional will change it sounds like, but the general warning still seems useful.

Edit I think you're pointing out that even after these changes, the context API DEV bits in the e.g. Text.js component can't be removed- but we think it can eventually be deprecated. Same with TextInput. (Tim and I have chatted about this a bit in meatspace.)

type: string,
): HostContext {
if (__DEV__) {
const oldIsInAParentText = parentHostContext.isInAParentText;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this way vs what you had before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid creating new wrapper objects if isInAParentText hasn't changed.

@sebmarkbage
Copy link
Collaborator

The issue is that if we still need the context in user space, what do we gain by adding it in the reconciler? Other than slightly more overhead.

What you could do is make this a prod context too and use it to swap to RCTVirtualText automatically in the renderer instead of in the wrapper. It's a bit unfortunate because we have to add conditionals that gets tested for every kind of view, not just text.

type: string,
): HostContext {
if (__DEV__) {
const oldIsInAParentText = parentHostContext.isInAParentText;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're only going in one direction. If oldIsInAParentText is true, you can bail out early.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't Text > View > Text valid while Text > View > (plain text) isn't?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Text -> View is not valid.

(It currently is for iOS but it soon won't be. Tim is changing this now.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @yungsters in case this is not correct

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait why? What do you do when you need to put a view inline in some text?

Copy link
Contributor Author

@bvaughn bvaughn May 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certain types of components (like Image) are allowed inside of Text - but RCTView itself (the <View> component) is not.

Hm. But I think I see what you're saying. Only treating this value as a one-way thing would suggest that this is allowed: <Text> -> <Image> -> "foo" (although this specific case would have a different error about Image not accepting children...)

Copy link
Contributor Author

@bvaughn bvaughn May 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is a better example of why we shouldn't only set this value one way:

<Text>
  <ScrollView>
    foo
  </ScrollView>
</Text>

I don't think this is valid. We should warn.

I'm going to add a test for this and back out the one-way change.

@bvaughn
Copy link
Contributor Author

bvaughn commented May 8, 2018

The issue is that if we still need the context in user space, what do we gain by adding it in the reconciler? Other than slightly more overhead.What you could do is make this a prod context too and use it to swap to RCTVirtualText automatically in the renderer instead of in the wrapper. It's a bit unfortunate because we have to add conditionals that gets tested for every kind of view, not just text.

Fair question! I pushed this kind of for discussion purposes more so than thinking it's definitely a good idea.

That being said, if you look at a diff like D7895382, we could remove the Context.Consumer from View (a component that probably appears many times in an app) if we didn't have to worry about DEV warning for nested <View> in <Text>. (Although I guess this is DEV only so maybe not a huge win.)

We could maybe also move the RCTVirtualText distinction and get rid of Context.Consumer in Text. (I think it would be nicer to remove this distinction from JavaScript entirely and just let the native side handle it- but I'm not sure if there are gotchas there.)

@yungsters
Copy link
Contributor

I think the solution to that is to fix the native side so that the difference between RCTVirtualText and RCTText isn't necessary to deal with in JS. Maybe. Although it might be nice to be able to optimize from JS.

I don't think there is disagreement on mitigating the need to deal with this in JS. In fact, I was just discussing this with @shergin and @mdvacca today. With the new UIManager implementations and our desire to re-think some of the core primitives, I see this being only a matter of time.

The issue is that if we still need the context in user space, what do we gain by adding it in the reconciler?

This pull request actually adds two checks to the reconciler:

  1. Disallow <View> inside of <Text>.
  2. Disallow strings outside of <Text> and <TextInput>.

The first check is definitely redundant today and we do not gain anything from adding it. The only thing that we get is that once all the native components no longer require the use of context at runtime, we'll also be able to clean up the check from View.js (without adding this back to the reconciler, which is non-trivial for anyone except probably you and @bvaughn).

The second check is actually not possible today in user space (e.g. Text.js or View.js) and the primary motivator for this pull request. Components only have visibility into the composed elements in children. Without decomposing them into primitives, we cannot know whether every child of a <View> is not a string.

I hope this makes sense.

@bvaughn
Copy link
Contributor Author

bvaughn commented May 10, 2018

I hope this makes sense.

It did 😄

@bvaughn
Copy link
Contributor Author

bvaughn commented May 10, 2018

I've removed warning in favor of console.error based on @yungsters's feedback. This way RN will show a redbox instead of a yellow box for this case.

Copy link
Contributor

@yungsters yungsters left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome. Thanks again for helping us make this much easier to debug in React Native.


type HostContext = {
isInAParentText: boolean,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In React Native, we've started adopting a common practice to use:

type HostContext = $ReadOnly<{|
  isInAParentText: boolean,
|}>;

This makes it so you cannot write to isInAParentText and disallows access of properties other than the ones supplied. But I'd rather you use what is common in the React repository.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I've been trying to use exact types when I think of it. I dig the $ReactOnly suggestion too.

getRootHostContext(): {} {
return emptyObject;
getRootHostContext(rootContainerInstance: Container): HostContext {
return __DEV__ ? {isInAParentText: false} : emptyObject;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised that emptyObject fulfills the type definition for HostContext. I would've expected isInAParentText to need to be optional (isInAParentText?: boolean), but maybe emptyObject is typed with something lossy like Object?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true. I'd guess emptyObject is resolving to an any type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually something more insidious is going on here. I can't make Flow report an error in this type no matter what I do, within the React native or fabric renderers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, given this type:

type HostContext = $ReadOnly<{|
  isInAParentText: boolean,
|}>;

Flow reports "no errors" if I add these lines inside of a method like createInstance() that accepts a hostContext: HostContext param:

hostContext.isInAParentText = false;

But if I change it to this, Flow fails:

(hostContext: HostContext).isInAParentText = false;

Covariant property isInAParentText incompatible with contravariant use in assignment of property isInAParentText

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the React repo is using flow-cli v0.61 and the latest version is v0.72 ~ not sure if this could be an old Flow bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolution: I was trying to reproduce inside of a conditional, and we're using an older version of Flow that has a bug (reproducible in the REPL) that does not properly handle this case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:o Should we upgrade Flow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I'll add this discussion point to our sync notes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a failing case:

type HostContext = $ReadOnly<{|
  isInAParentText: boolean                  
|}>;

const hostContext: HostContext = {
  isInAParentText: true
};

if (hostContext.thisPropertyDoesNotExist) {
  // And this one is a Boolean and should not be writable
  hostContext.isInAParentText = 'abc';
}

@bvaughn bvaughn dismissed sebmarkbage’s stale review May 11, 2018 15:51

Addressed initial feedback.

@bvaughn
Copy link
Contributor Author

bvaughn commented May 11, 2018

Based on an in-person conversation yesterday with Tim and Kevin about future RN direction, I've replaced the DEV-only checks that logged to console.error with an invariant. I did this because the invalid nesting will cause a crash on the native side, and it would be better for there to be a meaningful error on the JavaScript side in this event.

Back to @yungsters and @sebmarkbage for review and consideration.

Copy link
Contributor

@yungsters yungsters left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. Thanks!

@shergin
Copy link
Contributor

shergin commented May 12, 2018

It renders different components depending on where you are. @shergin is fixing this on iOS Fabric to not be needed but currently it's needed on the Android side.
I think the solution to that is to fix the native side so that the difference between RCTVirtualText and RCTText isn't necessary to deal with in JS. Maybe. Although it might be nice to be able to optimize from JS.

I am afraid, it is used in Fabric, and I don't plan to remove it. (In the first version of this component in Fabric that condition looked a bit different, but now it is identical to Paper.)

Even if it is probably possible, it is far from trivial and probably overcomplicate several things on the native side. Text and VirtualText accept different sets of props and behaves differently; and conceptually they are completely different things.

Ideally, the <Text props> should be just a syntax sugar that works like that:

let {textViewProps, viewProps, textAttributesProps, children} = props;
if (nested) {
  return <TextAttributes textAttributesProps children />;
} else {
  return (
    <TextView textViewProps viewProps>
      <TextAttributes textAttributesProps children />
    </TextView>
  );
}

@sophiebits
Copy link
Collaborator

@shergin Sending both Text and VirtualText for a single non-nested Text is an easy change to make on the JS side you prefer the two separated on the native side.

@shergin
Copy link
Contributor

shergin commented May 14, 2018

Yeah, sure.
BTW, I am totally okay with the PR, I was talking about far and idealistic future.

@bvaughn bvaughn merged commit c802d29 into facebook:master May 14, 2018
@bvaughn bvaughn deleted the rn-text branch May 14, 2018 22:34
@bvaughn
Copy link
Contributor Author

bvaughn commented May 14, 2018

Thanks all.

Once RN is ready on the native side for us to do more cleanup, let's do it! I'm happy to make more changes on the renderer side to enable this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants